Tab / Shift+Tab indent / outdent (4 spaces) Ctrl+/ toggle line comment Ctrl+S save gpuppy Ctrl+I trigger autocomplete (also auto after 2 chars or `u.` / `in.` / `Math.`) Ctrl+F / Ctrl+G find / find next Ctrl+H replace
Ctrl works on both macOS and Windows. On macOS, Cmd variants are intercepted by After Effects (Cmd+S saves the project, Cmd+F opens AE's own find).
A custom gpuppy only needs helper functions plus one fragment entry point. GPUDog injects the vertex shader, uniforms, input texture, noise textures, and layer-param bindings before your code.
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(input_texture, input_sampler, in.uv);
return src;
}
Use in.uv for normalized layer coordinates, in.frag_coord for full-resolution pixel math, and u.* for built-in or user parameters.
input_texture texture_2d<f32> the layer's pixels input_sampler sampler for textureSample(input_texture, ...) noise_2d texture_2d<f32> 256x256 RGBA8 white noise noise_sampler sampler shared by noise_2d, noise_3d and layer params noise_3d texture_3d<f32> 32x32x32 RGBA8 volume
let src = textureSample(input_texture, input_sampler, in.uv);
let src = textureSampleLevel(input_texture, input_sampler, in.uv, 0.0);
u.time f32 seconds since start u.resolution vec2<f32> full-res output size in pixels u.ratio f32 width / height (aspect) u.pixel_size vec2<f32> 1.0 / resolution
float -> f32 int -> i32 bool -> f32 stored as 0.0 / 1.0; use is_on(u.param) color -> vec4<f32> rgba, each channel in [0..1] angle -> f32 radians (converted from degrees) point -> vec2<f32> (x, y) popup -> i32 0-based index layer -> texture_2d + sampler
Access a user param as u.<snake_case_name>. Checkbox params live as f32 because WGSL uniform layout has no bool. When a gpuppy declares a checkbox param, GPUDog also injects:
is_on(v: f32) -> bool // true when v > 0.5
let mask_value = textureSample(mask, mask_sampler, in.uv).r;
in.uv vec2<f32> normalized [0, 1] over the layer in.frag_coord vec2<f32> fragment in full-res pixels (= uv * resolution) in.raw_position vec4<f32> raw rasterizer @builtin; advanced, reduced at Half/Third
After Effects renders previews at Full, Half, or Third resolution for speed. GPUDog exposes full-resolution space, so dots, blocks, and scanlines stay stable across preview qualities. Prefer in.frag_coord, u.resolution, and u.pixel_size; the invariant frag_coord * pixel_size == frag_coord / resolution == uv holds everywhere.
let cell = 8.0; // size in screen pixels let snapped = floor(in.frag_coord / cell) * cell + cell * 0.5; let cell_uv = snapped * u.pixel_size; // == snapped / u.resolution
let center = u.resolution * 0.5; let d = length(in.frag_coord - center); let mask = smoothstep(50.0, 48.0, d); // 50 px radius
uv-space ↔ p-space. uv-space is [0, 1], the raw layer texture coordinates used for textureSample and any distortion you feed back into it. p-space is [-ratio..ratio, -1..1], centred at origin and aspect-corrected so circles stay circles — preferred for SDF math and directional effects. Two transforms below convert positions (use ±0.5 centre offset), two convert displacement vectors (scale only, no centre offset because translation doesn't apply to vectors). All four use the same factor 2 · vec2(u.ratio, 1).
// uv ∈ [0,1] → p ∈ [-ratio..ratio, -1..1]. Centre at origin, scale ×2, // stretch x by aspect so circles stay circles. let p = (in.uv - 0.5) * 2.0 * vec2<f32>(u.ratio, 1.0);
// p ∈ [-ratio..ratio, -1..1] → uv ∈ [0,1]. Inverse of the snippet above. let uv = p / (2.0 * vec2<f32>(u.ratio, 1.0)) + 0.5;
// Vector — only scale, no centre offset (the 0.5 / -0.5 is a point // translation, irrelevant for vectors). Used with textureSample as // `in.uv + disp_uv`. let disp_uv = disp_p / (2.0 * vec2<f32>(u.ratio, 1.0));
// Inverse of the snippet above — scale only, no centre offset. let disp_p = disp_uv * 2.0 * vec2<f32>(u.ratio, 1.0);
fn rot2(a: f32) -> mat2x2<f32> {
let c = cos(a);
let s = sin(a);
return mat2x2<f32>(c, s, -s, c);
}
// angle params arrive as radians (host converts degrees → radians). // dir is a unit vector pointing along the angle — rotates as the // user drags the Angle control in AE. let dir = vec2<f32>(cos(u.angle), sin(u.angle));
// Rotate dir by +90° by swapping components and flipping one sign. // (dir, tangent) form an orthonormal basis aligned with the angle — // one axis points "across" the line, the other "along" it. // For -90° use vec2<f32>(dir.y, -dir.x). let tangent = vec2<f32>(-dir.y, dir.x);
// dot(a, b) with a unit-length b is the signed projection of a onto b. // n — normal coordinate (across the line, along dir). // t — tangent coordinate (along the line, along tangent). // Any "directional" effect splits into a function of n (the cut) // and t (modulation along the cut). let pos_px = in.uv / u.pixel_size; let center_px = vec2<f32>(0.5) / u.pixel_size; let rel = pos_px - center_px; let n = dot(rel, dir); // normal coordinate let t = dot(rel, tangent); // tangent coordinate
// nc = signed distance to the band centre along the normal axis. // d ramps from 0 inside the core to positive outside; ramp adds a // smoothstep falloff of `sm` pixels so the edge isn't hard. Use // sign(nc) to push pixels outward along dir (inward by flipping it). let nc = n - u.offset; let d = abs(nc) - w * 0.5 + sm * 0.5; let ramp = max(d, 0.0) * smoothstep(0.0, sm, d);
// edge_shape offsets n by something that depends on t, so the // "nc = 0" contour bends along the line instead of being straight. // Swap sin() for tri() for zig-zag, or a noise sample for organic. let edge_shape = sin(t * 0.03 + u.time) * 50.0; let nc = n - u.offset - edge_shape;
fn tri(x: f32) -> f32 {
let f = fract(x);
return abs(f * 2.0 - 1.0) * 2.0 - 1.0;
}
GPUDog injects a Math namespace into every shader — accessed AE-expressions-style as Math.PI, Math.TAU, Math.DEG2RAD, etc. It's a module-scope const struct; field access is folded at compile time, so there's no runtime cost. Built-in functions (cos, sin, pow, sqrt, mix, clamp, …) are plain global names in WGSL — call them without a namespace: cos(angle), not Math.cos(angle).
// Available fields on `Math` (auto-injected, do not redeclare): // Math.PI 3.14159265358979 // Math.TAU 6.28318530717958 // 2 * PI — one full turn // Math.HALF_PI 1.57079632679490 // PI / 2 // Math.INV_PI 0.31830988618379 // 1 / PI // Math.INV_TAU 0.15915494309189 // 1 / TAU // Math.SQRT_2 1.41421356237310 // Math.SQRT_3 1.73205080756888 // Math.PHI 1.61803398874989 // golden ratio // Math.E 2.71828182845905 // Euler's number // Math.DEG2RAD 0.01745329251994 // PI / 180 // Math.RAD2DEG 57.2957795130823 // 180 / PI // Math.EPS 1e-6 // safe denominator floor // Math.RIGHT vec2( 1, 0) // Math.LEFT vec2(-1, 0) // Math.UP vec2( 0, -1) // uv-y grows downward // Math.DOWN vec2( 0, 1) // Math.CENTER vec2(0.5, 0.5) // Math.LUMA_709 vec3(0.2126, 0.7152, 0.0722) // Math.LUMA_601 vec3(0.299, 0.587, 0.114) let theta = 45.0 * Math.DEG2RAD; // literal degrees → radians let turn = u.time * Math.TAU; // one full rotation per second
// usage: keep denominators positive, avoid NaN on degenerate inputs. let safe_len = max(length(v), Math.EPS); let unit = v / safe_len;
fn luma709(c: vec3<f32>) -> f32 {
return dot(c, Math.LUMA_709);
}
fn luma601(c: vec3<f32>) -> f32 {
return dot(c, Math.LUMA_601);
}
let y = luma709(src.rgb);
let uv_from_center = in.uv - Math.CENTER; let one_px_right = in.uv + Math.RIGHT * u.pixel_size.x; let one_px_up = in.uv + Math.UP * u.pixel_size.y;
noise_2d is a repeatable 256x256 RGBA texture of raw white noise — each texel is independent, so direct samples look grainy with hard texel edges. For smooth Perlin-style noise, wrap a sample in a quintic/hermite curve (smooth_noise below). For complexity, stack octaves via FBM. noise_3d is a 32³ volume; use time as z for smooth flicker-free animation.
let seed = in.uv + vec2<f32>(u.time * 0.1, 0.0); let n = textureSampleLevel(noise_2d, noise_sampler, seed, 0.0).r;
let p = vec3<f32>(in.uv * 4.0, u.time * 0.2); let v = textureSampleLevel(noise_3d, noise_sampler, p, 0.0).r;
// Trick: push the fractional part through a hermite S-curve BEFORE
// the texture lookup. The hardware bilinear filter then blends the
// 4 cell corners with a smooth cubic instead of a linear ramp →
// no diamond/triangle artefacts on the cell edges.
// p is in cell units; one cell ≈ one screen feature.
// Call as: smooth_noise(in.uv * 6.0 + vec2f(u.time, 0.0))
fn smooth_noise(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let h = f * f * (3.0 - 2.0 * f); // hermite
let nuv = (i + h + 0.5) / 256.0; // 256² tex
return textureSampleLevel(noise_2d, noise_sampler, nuv, 0.0).r;
}
// `octaves` is the complexity dial — expose it as an int param
// (e.g. "Detail" 1..8). Each octave doubles frequency and halves
// amplitude; gain < 0.5 keeps the result tame, > 0.5 adds bite.
// Needs `smooth_noise` from the previous snippet.
// Call as: fbm(in.uv * 4.0, clamp(u.detail, 1, 8), 0.5)
fn fbm(p: vec2<f32>, octaves: i32, gain: f32) -> f32 {
var sum = 0.0;
var amp = 0.5;
var norm = 0.0;
var q = p;
for (var i = 0; i < octaves; i = i + 1) {
sum = sum + amp * smooth_noise(q);
norm = norm + amp;
q = q * 2.07 + vec2<f32>(13.7, 7.13); // de-aligns octaves
amp = amp * gain;
}
return sum / norm;
}
// Cheaper, but cell edges show through at high octaves (1 sample
// per octave, hardware linear only). Use smooth_noise + fbm above
// when artefacts matter.
fn fbm_rgba(p: vec2<f32>) -> vec4<f32> {
var f = vec4<f32>(0.0);
var amp = 0.5;
var q = p;
var norm = 0.0;
for (var i = 0; i < 6; i = i + 1) {
f = f + amp * textureSampleLevel(noise_2d, noise_sampler, q, 0.0);
q = q * 2.07 + vec2<f32>(13.7, 7.13);
norm = norm + amp;
amp = amp * 0.5;
}
return f / norm;
}
fn luma709(c: vec3<f32>) -> f32 {
return dot(c, Math.LUMA_709);
}
fn adjust_saturation(c: vec3<f32>, amount: f32) -> vec3<f32> {
let y = luma709(c);
return mix(vec3<f32>(y), c, amount);
}
let rgb = adjust_saturation(src.rgb, 1.4);
fn brightness_contrast(v: f32, brightness: f32, contrast: f32) -> f32 {
let cn = contrast * 0.02; // 0..100 -> 0..2, 50 = 1
let br = (brightness - 50.0) * 0.02; // 0..100 -> -1..+1, 50 = 0
return saturate((v - 0.5) * cn + 0.5 + br);
}
let y = brightness_contrast(luma709(src.rgb), u.brightness, u.contrast);
// Smooth, periodic colour ramp. R/G/B are cosines with offset phases:
// `hue` rotates the whole palette (0..1 cycles through hues)
// `spread` controls channel separation — 0 = grayscale,
// 1 = full rainbow (R/G/B at 120° / 240°)
// Returns rgb in [0, 1]; pass output through pow for gamma if needed.
// Call as: layer_col = cos_palette(t * u.palette_cycles * 0.01, -u.hue_shift, u.palette_spread);
fn cos_palette(t: f32, hue: f32, spread: f32) -> vec3<f32> {
let d = vec3<f32>(
hue,
hue + spread * (Math.TAU / 3.0),
hue + spread * (Math.TAU * 2.0 / 3.0),
);
return 0.5 + 0.5 * cos(t * Math.TAU + d);
}
// c.x = hue (0..1, wraps), c.y = saturation (0..1), c.z = value (0..1).
// Branchless implementation — no warp divergence, runs at constant cost
// regardless of input. Sami Sarpola / Sam Hocevar trick.
// Call as: hsv2rgb(vec3f(u.time * 0.1, 0.8, 1.0))
fn hsv2rgb(c: vec3<f32>) -> vec3<f32> {
let K = vec4<f32>(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
let p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, vec3<f32>(0.0), vec3<f32>(1.0)), c.y);
}
const WL_RED: f32 = 750.0;
const WL_BLUE: f32 = 350.0;
const SPECTRAL_SAMPLES: i32 = 16;
// vec4 so alpha is preserved — Rgba16Float pipeline expects rgba out.
fn spectral_sample(uv: vec2<f32>, offset: vec2<f32>, dispersion: f32) -> vec4<f32> {
var accum = vec4<f32>(0.0);
var w_sum = vec4<f32>(0.0);
for (var i = 0; i < SPECTRAL_SAMPLES; i = i + 1) {
let t = f32(i) / f32(SPECTRAL_SAMPLES - 1);
let wl = mix(WL_RED, WL_BLUE, t);
let weight = clamp(vec3<f32>(
-22.031 + 0.06936 * wl - 0.00005115 * wl * wl,
-26.168 + 0.10105 * wl - 0.00009368 * wl * wl,
-36.855 + 0.17063 * wl - 0.00019177 * wl * wl,
), vec3<f32>(0.0), vec3<f32>(1.0));
let weight4 = vec4<f32>(weight, 1.0); // alpha summed with unit weight
let wl_scale = 1.0 + dispersion * (t - 0.5);
let sample_uv = uv + offset * wl_scale;
accum = accum + textureSample(input_texture, input_sampler, sample_uv) * weight4;
w_sum = w_sum + weight4;
}
return accum / w_sum;
}
GPUDog ships a built-in MSDF font atlas: four fonts with Latin + Cyrillic + Japanese kana coverage, plus a shared box-drawing / blocks / geometric symbol set (U+2500..U+25FF). Glyphs stay crisp at any scale.
font_id 0 Tiny5 pixel, tiny font_id 1 Pixelify Sans pixel font_id 2 IBM Plex Mono clean monospace font_id 3 Six Caps condensed caps display (Latin only; Cyrillic falls back to Latin twins) kana fonts 0/1 -> DotGothic16 (pixel) font 2 -> Kosugi Maru (rounded)
ascii_glyph(font_id, code, cell_uv) -> f32 // AA coverage of one glyph, cell_uv in [0,1]
glyph_at(code, p, org, sz, font_id) -> f32 // one glyph inside a pixel rect
text_cell(p, origin, char_size,
spacing_pct, line_spacing_pct)
-> TextCell { col, row, uv, mask } // uniform-grid layout, glyph at natural size
print_value(value, p, org, sz,
int_digits, frac_digits, font_id) // debug numbers on screen
#text blocks turn raw text lines into lookup functions at compile time — no manual codepoint arrays, no hand-counted offsets. Characters outside the atlas are a compile error. Each block generates NAME(line, col) (codepoint, 0x20 outside), NAME_len(line), NAME_LINES and NAME_MAXLEN. For one-line charset ramps there is the #chars one-liner: NAME(i) (clamped index), NAME_t(t) (normalized [0,1) → glyph) and NAME_LEN.
#chars RAMP " .:-=+*#%@" // quotes keep the leading space #chars MATRIX "'./ノハメラマ1" // light -> dense by luminance: let code = RAMP_t(lum); // t in [0,1) -> glyph codepoint let g = ascii_glyph(u.font_id, code, cuv);
#text GREETING
HELLO ● WORLD
#endtext
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let pos = u.resolution * 0.5;
let tc = text_cell(in.frag_coord, pos, 48.0, 110.0, 100.0);
let g = ascii_glyph(2, GREETING(0, tc.col), tc.uv)
* tc.mask * f32(tc.row == 0);
let src = textureSample(input_texture, input_sampler, in.uv);
return mix(src, vec4<f32>(1.0), g);
}
let typed = i32(u.time * 12.0); // characters per second
let visible = f32(tc.col < min(typed, GREETING_len(0)));
let g = ascii_glyph(2, GREETING(0, tc.col), tc.uv)
* tc.mask * visible * f32(tc.row == 0);
#text JP ジーピーユードッグ ひらがな も つかえます #endtext
#text SETS ВЫЙДИ ИЗ GPUТРИКС GPU ● ШЕЙДЕРЫ ● МАГИЯ #endtext // let line = clamp(u.text, 0, SETS_LINES - 1); // popup param "text" // let code = SETS(line, tc.col);
#text FRAME ┌──────────┐ │ ░▒▓█ ●◆▲ │ └──────────┘ #endtext
var word = array<i32, 4>(_G, _D, _O, _G); // _A.._z, _0.._9, _SP, _DOT... let dot = ascii_glyph(1, _CIRCLE, cuv); // ● — curated symbol consts let any = ascii_glyph(1, 0x25D0, cuv); // ◐ — or any codepoint directly let cyr = ascii_glyph(0, c_YA, cuv); // Я — Cyrillic c_* consts
let dbg = print_value(u.time, in.frag_coord,
vec2<f32>(20.0), vec2<f32>(14.0, 20.0), 4, 2, 2);
return max(src, vec4<f32>(dbg));
2D shape library below: WGSL ports of Inigo Quilez's exact 2D SDFs (iquilezles.org/articles/distfunctions2d). All signed (negative inside), centered at origin, y up. WGSL note: function params are immutable — copy to var p = p_in; first; GLSL's mod() is floor-based, WGSL % truncates, so use x - y*floor(x/y) for angles.
Transforming a shape — apply the transform to the sample point, not the distance (rot2 is in the Coordinates chapter). Rotation is an isometry so distance is preserved; for a uniform scale divide the result back to keep a true SDF (so smoothstep AA and outlines stay correct); non-uniform scale only approximates the distance.
let p = rot2(u.angle) * q; // rotating the POINT spins the shape let d = sd_pentagram(p, 1.0); // rotation preserves distance
let s = 1.3; // divide the point, multiply the result let d = sd_hexagon(q / s, 0.8) * s;
fn sd_equilateral_triangle(p_in: vec2f, r: f32) -> f32 {
let k = sqrt(3.0);
var p = p_in;
p.x = abs(p.x) - r;
p.y = p.y + r / k;
if (p.x + k * p.y > 0.0) {
p = vec2f(p.x - k * p.y, -k * p.x - p.y) / 2.0;
}
p.x -= clamp(p.x, -2.0 * r, 0.0);
return -length(p) * sign(p.y);
}
fn sd_pentagon(p_in: vec2f, r: f32) -> f32 {
let k = vec3f(0.809016994, 0.587785252, 0.726542528);
var p = p_in;
p.x = abs(p.x);
p -= 2.0 * min(dot(vec2f(-k.x, k.y), p), 0.0) * vec2f(-k.x, k.y);
p -= 2.0 * min(dot(vec2f( k.x, k.y), p), 0.0) * vec2f( k.x, k.y);
p -= vec2f(clamp(p.x, -r * k.z, r * k.z), r);
return length(p) * sign(p.y);
}
fn sd_hexagon(p_in: vec2f, r: f32) -> f32 {
let k = vec3f(-0.866025404, 0.5, 0.577350269);
var p = abs(p_in);
p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
p -= vec2f(clamp(p.x, -k.z * r, k.z * r), r);
return length(p) * sign(p.y);
}
// https://www.shadertoy.com/view/llGfDG
fn sd_octagon(p_in: vec2f, r: f32) -> f32 {
let k = vec3f(-0.9238795325, 0.3826834323, 0.4142135623);
var p = abs(p_in);
p -= 2.0 * min(dot(vec2f( k.x, k.y), p), 0.0) * vec2f( k.x, k.y);
p -= 2.0 * min(dot(vec2f(-k.x, k.y), p), 0.0) * vec2f(-k.x, k.y);
p -= vec2f(clamp(p.x, -k.z * r, k.z * r), r);
return length(p) * sign(p.y);
}
fn sd_pentagram(p_in: vec2f, r: f32) -> f32 {
let k1x = 0.809016994; // cos(pi/5)
let k2x = 0.309016994; // sin(pi/10)
let k1y = 0.587785252; // sin(pi/5)
let k2y = 0.951056516; // cos(pi/10)
let k1z = 0.726542528; // tan(pi/5)
let v1 = vec2f( k1x, -k1y);
let v2 = vec2f(-k1x, -k1y);
let v3 = vec2f( k2x, -k2y);
var p = p_in;
p.x = abs(p.x);
p -= 2.0 * max(dot(v1, p), 0.0) * v1;
p -= 2.0 * max(dot(v2, p), 0.0) * v2;
p.x = abs(p.x);
p.y -= r;
return length(p - v3 * clamp(dot(p, v3), 0.0, k1z * r))
* sign(p.y * v3.x - p.x * v3.y);
}
fn sd_star(p_in: vec2f, r: f32, n: i32, m: f32) -> f32 {
let an = 3.141593 / f32(n);
let en = 3.141593 / m;
let acs = vec2f(cos(an), sin(an));
let ecs = vec2f(cos(en), sin(en)); // ecs = vec2f(0,1) for a regular polygon
var p = p_in;
// GLSL mod() is floor-based; WGSL % truncates -> do it by hand.
let a2 = 2.0 * an;
let at = atan2(p.x, p.y);
let bn = (at - a2 * floor(at / a2)) - an;
p = length(p) * vec2f(cos(bn), abs(sin(bn)));
p -= r * acs;
p += ecs * clamp(-dot(p, ecs), 0.0, r * acs.y / ecs.y);
return length(p) * sign(p.x);
}
fn dot2(v: vec2f) -> f32 { return dot(v, v); }
// https://www.shadertoy.com/view/3tyBzV
fn sd_heart(p_in: vec2f) -> f32 {
var p = p_in;
p.x = abs(p.x);
if (p.y + p.x > 1.0) {
return sqrt(dot2(p - vec2f(0.25, 0.75))) - sqrt(2.0) / 4.0;
}
return sqrt(min(dot2(p - vec2f(0.0, 1.0)),
dot2(p - 0.5 * max(p.x + p.y, 0.0)))) * sign(p.x - p.y);
}
// b.x = arm length, b.y = arm thickness, r rounds the corners.
// https://www.shadertoy.com/view/XtGfzw
fn sd_cross(p_in: vec2f, b: vec2f, r: f32) -> f32 {
var p = abs(p_in);
p = select(p, p.yx, p.y > p.x);
let q = p - b;
let k = max(q.y, q.x);
let w = select(vec2f(b.y - p.x, -k), q, k > 0.0);
return sign(k) * length(max(w, vec2f(0.0))) + r;
}
// https://www.shadertoy.com/view/3dKSDc
fn sd_rounded_x(p_in: vec2f, w: f32, r: f32) -> f32 {
let p = abs(p_in);
return length(p - min(p.x + p.y, w) * 0.5) - r;
}
fn sd_circle(p: vec2<f32>, r: f32) -> f32 {
return length(p) - r;
}
let d = sd_circle(in.frag_coord - u.resolution * 0.5, 80.0);
let mask = smoothstep(1.0, -1.0, d);
// Crosshair as the union of two capped line segments (a "+"). Each
// segment is the SDF of an axis-aligned line: max(perpendicular axis,
// along-axis - arm). `arm_length` controls how far each arm extends
// from the centre; raise it past your draw area to recover an
// infinite cross. Same shape as sd_circle: returns raw distance,
// smoothstep at the call site.
// Pass position in (dir, tangent) basis as vec2f(n, t) for an
// angle-aligned cross, or `in.uv - 0.5` for an axis-aligned one.
// Call as: let d = sd_cross(in.uv - 0.5, 0.005, 0.3);
// let mask = 1.0 - smoothstep(0.0, 0.002, d);
fn sd_cross(p: vec2<f32>, thickness: f32, arm_length: f32) -> f32 {
let v = max(abs(p.x), abs(p.y) - arm_length); // vertical arm
let h = max(abs(p.y), abs(p.x) - arm_length); // horizontal arm
return min(v, h) + thickness;
}
// Arrow as union of a rounded shaft + a triangular head.
// Pure SDF — negative inside, positive outside, like sd_circle.
// Compose with min() against other SDFs, smoothstep at call site.
// p — pixel position relative to arrow base
// v — arrow vector (direction + length, in same units as p)
// shaft_w — half-width of the shaft
// head_len — length of the head along v (base half-width == head_len*0.5)
// Zero-vector v degenerates to a dot of radius shaft_w at origin.
// Call as: let d = sd_arrow(p, vec2f(60.0, 0.0), 3.0, 18.0);
// let mask = 1.0 - smoothstep(0.0, 1.5, d);
fn sd_arrow(p: vec2<f32>, v: vec2<f32>, shaft_w: f32, head_len: f32) -> f32 {
let mag = length(v);
if (mag < 0.0001) { return length(p) - shaft_w; }
let dir = v / mag;
let perp = vec2<f32>(-dir.y, dir.x);
let n = dot(p, dir); // along arrow
let t = abs(dot(p, perp)); // perpendicular distance
// Shaft: rectangle [0, mag - head_len*0.5] × [-shaft_w, shaft_w]
let shaft_end = mag - head_len * 0.5;
let shaft = max(t - shaft_w, max(-n, n - shaft_end));
// Head: isoceles triangle, apex at n=mag, base at n=mag-head_len,
// base half-width = head_len * 0.5 (45° opening).
let h_back = (mag - head_len) - n; // negative inside (past base)
let h_taper = t - (mag - n) * 0.5; // negative inside taper
let head = max(h_back, h_taper);
return min(shaft, head);
}
// L1-norm distance to an axis-aligned rhombus with half-radii (rx, ry),
// scaled by min(rx, ry) so the metric is roughly Euclidean near the
// surface (good enough for smoothstep masking and SDF composition).
// Negative inside, positive outside.
// Call as: let d = sd_diamond(p - center, vec2f(0.4, 0.5));
fn sd_diamond(p: vec2<f32>, radius: vec2<f32>) -> f32 {
let d = abs(p);
return (d.x / radius.x + d.y / radius.y - 1.0) * min(radius.x, radius.y);
}
// Chevron = outer rhombus MINUS the same rhombus shifted left.
// Boolean subtraction in SDF land: `max(outer, -inner)`. The thickness
// arg controls how far the inner diamond is shifted — wider gap → bolder
// chevron. Flip the sign of `thickness` to mirror the chevron (`<`).
// Requires `sd_diamond` above.
// Call as: let d = sd_chevron(p, vec2f(0.4, 0.5), 0.2);
// let mask = 1.0 - smoothstep(0.0, 0.01, d);
fn sd_chevron(p: vec2<f32>, radius: vec2<f32>, thickness: f32) -> f32 {
let outer = sd_diamond(p, radius);
let inner = sd_diamond(p + vec2<f32>(thickness, 0.0), radius);
return max(outer, -inner);
}
Debug visualisation. Most shader quantities — a signed distance, a 2D displacement, a gradient, a surface normal — are invisible in the final image until you map them onto colour. These helpers draw an otherwise-hidden value so you can see what the maths is doing. Gate them behind a checkbox param and remove (or hide) before shipping a gpuppy.
// Visualise a signed scalar: pick `neg` or `pos` tint by sign, modulate
// by |v|. Brightness = magnitude, so 0 stays black. `select(a, b, cond)`
// is WGSL's branchless if-else — returns `b` when cond is true.
// Call as: debug_signed(value, vec3f(1,0,0), vec3f(1,1,1))
fn debug_signed(v: f32, neg: vec3<f32>, pos: vec3<f32>) -> vec4<f32> {
let tint = select(neg, pos, v > 0.0);
return vec4<f32>(tint * abs(v), 1.0);
}
Vector field debug viz. When a shader produces a 2D quantity per pixel (displacement, gradient, flow, normal) it's invisible in the final image until you visualise it directly. The arrow-tile technique below covers the screen with a regular grid of cells (tile size in pixels), samples the field once at each tile centre, and draws a coloured arrow encoding that sample: direction → hue (rotation around the colour wheel via atan2) and magnitude → brightness (length normalised to half-tile). The tile grid implicitly low-passes the field — pick tile_size bigger than the smallest feature you want to read, small enough to see local variation. Render the arrow layer with its own alpha mask and mix on top of your normal output so it stays an overlay, gated behind a debug toggle. Used during development and removed (or hidden behind a checkbox param) before shipping a gpuppy.
// Renders a regular grid of arrows showing a 2D vector field — perfect
// for debugging displacement / gradient / flow maps. Each tile centre
// samples your field (`normal` arg) and draws an arrow + colour based
// on direction (hue) and magnitude (brightness). Requires `cos_palette`
// from above for the colour ramp.
//
// Call as (typically wrapped in a checkbox debug toggle):
// let arrows = draw_vector_field(in.frag_coord, u.resolution,
// u.tile_size, my_displacement_field);
// return vec4f(mix(col.rgb, arrows.rgb, arrows.a), col.a);
const ARROW_HEAD_ANGLE: f32 = Math.HALF_PI; // 90° (one quarter turn)
const ARROW_SHAFT_THICKNESS: f32 = 2.0;
fn arrow_tile_center_coord(pos_px: vec2<f32>, tile_size: f32) -> vec2<f32> {
return (floor(pos_px / tile_size) + 0.5) * tile_size;
}
// SDF-like mask for a single arrow inside one tile.
// p — pixel position relative to tile centre
// v_px — arrow vector in pixels (direction + length)
fn arrow(p: vec2<f32>, v_px: vec2<f32>, tile_size: f32) -> f32 {
let arrow_head_length = tile_size / 6.0;
var mag_v = length(v_px);
let mag_p = length(p);
// Zero-vector: draw a small dot at tile centre instead of an arrow.
if (mag_v <= 0.0001) {
return max(0.0, 1.2 - mag_p);
}
let dir_v = v_px / mag_v;
mag_v = clamp(mag_v, 5.0, tile_size * 0.5);
let v = dir_v * mag_v;
let shaft = ARROW_SHAFT_THICKNESS * tile_size * 0.02 -
max(abs(dot(p, vec2<f32>(dir_v.y, -dir_v.x))),
abs(dot(p, dir_v)) - mag_v + arrow_head_length * 0.5);
let head_front = min(0.0,
dot(v - p, dir_v) - cos(ARROW_HEAD_ANGLE * 0.5) * length(v - p)) * 2.0;
let head_back = min(0.0, dot(p, dir_v) + arrow_head_length - mag_v);
return clamp(1.0 + max(shaft, head_front + head_back), 0.0, 1.0);
}
// Direction → hue, magnitude → brightness. Zero vector → black.
fn vector_sine_colormap(v: vec2<f32>, tile_size: f32) -> vec3<f32> {
let mag = clamp(length(v) / (tile_size * 0.5), 0.0, 1.0);
let a = atan2(v.y, v.x);
return saturate(cos_palette(a * 0.5, 1.0, mag) * mag);
}
// Compose: snap to tile, draw arrow, colour by direction.
// Returns vec4f(rgb, mask) — alpha is the arrow occupancy in this pixel.
fn draw_vector_field(
frag_coord: vec2<f32>, resolution: vec2<f32>,
tile_size: f32, normal: vec2<f32>,
) -> vec4<f32> {
let tile_center = arrow_tile_center_coord(frag_coord, tile_size);
let p = frag_coord - tile_center;
let arrow_mask = saturate(arrow(p, -normal, tile_size));
let arrow_col = vector_sine_colormap(-normal, tile_size);
return vec4<f32>(arrow_col, arrow_mask);
}
let src = textureSample(input_texture, input_sampler, in.uv); let fx = vec3<f32>(1.0, 0.35, 0.08); let out_rgb = mix(src.rgb, fx, 0.35); return vec4<f32>(out_rgb, src.a);
fn screen(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
let one = vec3<f32>(1.0);
return one - (one - a) * (one - b);
}
fn overlay_ch(base: f32, blend: f32) -> f32 {
return select(1.0 - 2.0 * (1.0 - base) * (1.0 - blend), 2.0 * base * blend, base < 0.5);
}
fn overlay(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
return vec3<f32>(overlay_ch(base.r, blend.r), overlay_ch(base.g, blend.g), overlay_ch(base.b, blend.b));
}
fn difference(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
return abs(a - b);
}
fn exclusion(a: vec3<f32>, b: vec3<f32>) -> vec3<f32> {
return a + b - 2.0 * a * b;
}
fn composite_over(fg: vec4<f32>, bg: vec4<f32>) -> vec4<f32> {
let a = fg.a + bg.a * (1.0 - fg.a);
let rgb = (fg.rgb * fg.a + bg.rgb * bg.a * (1.0 - fg.a)) / max(a, Math.EPS);
return vec4<f32>(rgb, a);
}
let out_col = composite_over(vec4<f32>(fx_rgb, fx_alpha), src);
fn source_out(src: vec4<f32>, matte_alpha: f32) -> vec4<f32> {
return vec4<f32>(src.rgb, src.a * (1.0 - saturate(matte_alpha)));
}
fn source_in(src: vec4<f32>, matte_alpha: f32) -> vec4<f32> {
return vec4<f32>(src.rgb, src.a * saturate(matte_alpha));
}
let p = (in.uv - 0.5) * 2.0 * vec2<f32>(u.ratio, 1.0); let ro = vec3<f32>(0.0, 0.0, -3.0); let rd = normalize(vec3<f32>(p, 1.5));
fn fresnel_schlick(cos_theta: f32, f0: f32) -> f32 {
return f0 + (1.0 - f0) * pow(1.0 - cos_theta, 5.0);
}
Author & tweak effects in the browser in real time, with no render waits. Then sync them straight to the gpuppies on your machine.
Browse, fetch & remix what the pack makes.
In the panel & the Den.
Included — no sign-in or cloud.
Describe a look, get WGSL.